package com.avcompris.util;

import static com.avcompris.util.ExceptionUtils.nonNullArgument;
import static java.lang.Integer.parseInt;
import static org.apache.commons.io.FileUtils.openInputStream;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.io.StringReader;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.Source;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.sax.SAXSource;
import javax.xml.transform.stream.StreamSource;
import javax.xml.validation.Schema;
import javax.xml.validation.SchemaFactory;
import javax.xml.validation.Validator;

import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.xml.sax.Attributes;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.AttributesImpl;

import com.avcompris.common.annotation.Nullable;

/**
 * XML utilities.
 * 
 * @author David Andriana Copyright Avantage Compris SARL 2008-2009 ©
 */

public class XMLUtils extends AbstractUtils {

	/**
	 * well-known doctypes.
	 */
	public static enum Doctype {

		HTML_4_01_Transitional("<!DOCTYPE html PUBLIC "
				+ "\"//W3C//DTD HTML 4.01 Transitional//EN\">"),

		XHTML_1_0_Transitional(
				"<!DOCTYPE html PUBLIC "
						+ "\"-//W3C//DTD XHTML 1.0 Transitional//EN\" "
						+ "\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">");

		/**
		 * the doctype string literal.
		 */
		public final String doctype;

		/**
		 * constructor.
		 * 
		 * @param doctype
		 *            the doctype string literal
		 */
		private Doctype(final String doctype) {

			this.doctype = doctype;
		}
	}

	/**
	 * Gets the resource as string.
	 * 
	 * @param resourceName
	 *            the resource name
	 * @return the resource as string
	 * @throws IOException
	 *             Signals that an I/O exception has occurred.
	 */
	public static String getResourceAsString(final String resourceName) throws IOException {

		nonNullArgument(resourceName, "resourceName");

		final InputStream xmlStream = XMLUtils.class
				.getResourceAsStream(resourceName);
		if (xmlStream == null) {
			throw new IOException("Cannot find named resource: " + resourceName);
		}
		try {

			final StringBuilder stringBuilder = new StringBuilder();

			final Reader reader = new InputStreamReader(xmlStream);
			try {

				final BufferedReader br = new BufferedReader(reader);
				try {

					while (true) {

						final String line = br.readLine();

						if (line == null) {

							return stringBuilder.toString();
						}

						stringBuilder.append(line).append("\n");
					}

				} finally {
					br.close();
				}
			} finally {
				reader.close();
			}
		} finally {
			xmlStream.close();
		}
	}

	/**
	 * escape characters to XML entities from an Unicode {@link String}. Encoded
	 * characters are:
	 * <ul>
	 * <li>[&amp;] =&gt; &amp;amp;
	 * <li>[&lt;] =&gt; &amp;lt;
	 * <li>[&gt;] =&gt; &amp;gt;
	 * <li>["] =&gt; &amp;quot;
	 * <li>['] =&gt; &amp;apos;
	 * <li>any non-ASCII character
	 * </ul>
	 * This method works for text values just as for attribute values.
	 * 
	 * @param s
	 *            the {@link String} to escape
	 * @return the escaped {@link String}
	 */
	public static String xmlEntities(final String s) {

		nonNullArgument(s, "s");

		final StringBuilder sb = new StringBuilder();

		for (final char c : s.toCharArray()) {

			if (c == '&') {
				sb.append("&amp;");
			} else if (c == '<') {
				sb.append("&lt;");
			} else if (c == '>') {
				sb.append("&gt;");
			} else if (c == '"') {
				sb.append("&quot;");
			} else if (c == '\'') {
				sb.append("&apos;");
			} else if (c >= 0 && c < 127) {
				sb.append(c);
			} else {

				sb.append("&#").append((int) c).append(';');
			}
		}

		return sb.toString();
	}

	/**
	 * escape characters to XML entities from an Unicode {@link String}. Encoded
	 * characters are:
	 * <ul>
	 * <li>[&amp;] =&gt; &amp;amp;
	 * <li>[&lt;] =&gt; &amp;lt;
	 * <li>[&gt;] =&gt; &amp;gt;
	 * <li>any non-ASCII character
	 * </ul>
	 * This method works for text values but not for attribute values, since
	 * "&amp;quot;" is not encoded. Use {@link #xmlEntities(String)} to have
	 * "&amp;quot;" encoded.
	 * 
	 * @param s
	 *            the {@link String} to escape
	 * @return the escaped {@link String}
	 */
	public static String xmlTextEntities(final String s) {

		nonNullArgument(s, "s");

		final StringBuilder sb = new StringBuilder();

		for (final char c : s.toCharArray()) {

			if (c == '&') {
				sb.append("&amp;");
			} else if (c == '<') {
				sb.append("&lt;");
			} else if (c == '>') {
				sb.append("&gt;");
			} else if (c >= 0 && c < 127) {
				sb.append(c);
			} else {

				sb.append("&#").append((int) c).append(';');
			}
		}

		return sb.toString();
	}

	/**
	 * convert a XML text, where non-ASCII characters such as 'é' may appear as
	 * '<tt>&amp;#233;</tt>', or even 'é', into a Canonical Unicode
	 * {@link String} in which those characters are set back to Unicode.
	 * 
	 * @param text
	 *            the original HTML text
	 * @return the Canonical HTML US-ASCII {@link String}
	 */

	public static String resolveXmlEntitiesToString(final String text) {

		nonNullArgument(text, "text");

		final StringBuilder t = new StringBuilder();

		final char[] chars = text.toCharArray();

		for (int i = 0; i < chars.length; ++i) {

			final char c = chars[i];

			if (c == '&') {

				final int i0 = i;

				++i;

				if (i >= chars.length) {
					t.append('&');
					break;
				}

				final char c1 = chars[i];

				final StringBuilder sb = new StringBuilder();

				sb.append(c).append(c1);

				final boolean isDecimalEntity = c1 == '#';

				while (true) {

					++i;

					if (i >= chars.length) {

						i = i0;

						sb.setLength(0);

						sb.append("&amp;");

						break;
					}

					final char c2 = chars[i];

					sb.append(c2);

					if (c2 == ';') {

						break;
					}

				}

				final String s = sb.toString();

				if (isDecimalEntity) {

					final int code;

					try {

						code = parseInt(s.substring(2, s.length() - 1));

					} catch (final NumberFormatException e) {

						throw new NumberFormatException(
								"Cannot parse HTML entity: \"" + s + "\".");
					}

					t.append((char) code);

				} else if ("&amp;".equals(s)) {
					t.append('&');
				} else if ("&lt;".equals(s)) {
					t.append('<');
				} else if ("&gt;".equals(s)) {
					t.append('>');
				} else if ("&quot;".equals(s)) {
					t.append('"');
				} else {

					throw new IllegalArgumentException("Unknown XML entity: "
							+ s);
				}

			} else {

				t.append(c);
			}
		}

		final String result = t.toString();

		return result;
	}

	/**
	 * read an XML File and return the corresponding node.
	 * 
	 * @param file
	 *            the XML File to read.
	 * @return the loaded node.
	 */
	public static Element readXMLFile(final File file) throws IOException, ParserConfigurationException, SAXException {

		nonNullArgument(file, "file");

		if (!file.exists()) {
			throw new FileNotFoundException(file.getCanonicalPath());
		}

		final DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory
				.newInstance();

		documentBuilderFactory.setNamespaceAware(true);

		final DocumentBuilder documentBuilder = documentBuilderFactory
				.newDocumentBuilder();

		final Document document = documentBuilder.parse(file);

		return document.getDocumentElement();
	}

	/**
	 * read an XML File and return the corresponding node.
	 * 
	 * @param text the XML content to read.
	 * @return the loaded node.
	 */
	public static Element readXMLContent(final String text) throws IOException, ParserConfigurationException, SAXException {

		nonNullArgument(text, "text");

		final DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory
				.newInstance();

		documentBuilderFactory.setNamespaceAware(true);

		final DocumentBuilder documentBuilder = documentBuilderFactory
				.newDocumentBuilder();

		final Document document = documentBuilder.parse(new InputSource(
				new StringReader(text)));

		return document.getDocumentElement();
	}

	/**
	 * validate a XML Schema.
	 * 
	 * @param schemaStream
	 *            the stream where the XML Schema stays.
	 * @param xml
	 *            the XML content
	 */
	public static void validateSchema(final InputStream schemaStream,
			final String xml) throws IOException, SAXException {

		nonNullArgument(xml, "xml");

		validateSchema(schemaStream, new SAXSource(new InputSource(
				new StringReader(xml))));
	}

	/**
	 * validate a XML Schema.
	 * 
	 * @param schemaStream
	 *            the stream where the XML Schema stays.
	 * @param xmlFile
	 *            the XML file
	 */
	public static void validateSchema(final InputStream schemaStream,
			final File xmlFile) throws IOException, SAXException {

		nonNullArgument(schemaStream, "schemaStream");
		nonNullArgument(xmlFile, "xmlFile");

		validateSchema(schemaStream, new SAXSource(new InputSource(
				openInputStream(xmlFile))));
	}

	/**
	 * validate a XML Schema.
	 * 
	 * @param schemaStream
	 *            the stream where the XML Schema stays.
	 * @param source
	 *            the XML source to parse
	 */
	private static void validateSchema(final InputStream schemaStream,
			final Source source) throws SAXException, IOException {

		nonNullArgument(schemaStream, "schemaStream");
		nonNullArgument(source, "source");

		final SchemaFactory schemaFactory = SchemaFactory
				.newInstance("http://www.w3.org/2001/XMLSchema");

		try {

			final Source schemaSource = new StreamSource(schemaStream);

			final Schema schema;

			try {

				schema = schemaFactory.newSchema(schemaSource);

			} catch (final SAXException e) {

				throw new RuntimeException("Cannot load XML Schema.", e);
			}

			final Validator validator = schema.newValidator();

			validator.validate(source);

		} finally {

			schemaStream.close();
		}
	}

	/**
	 * validate a XML Schema.
	 * 
	 * @param schemaStream
	 *            the stream where the XML Schema stays.
	 * @param node
	 *            the XML content
	 */
	public static void validateSchema(final InputStream schemaStream,
			final Node node) throws IOException, SAXException {

		nonNullArgument(node, "node");

		validateSchema(schemaStream, new DOMSource(node));
	}

	/**
	 * return the attribute value of a {@link Node}, or <tt>null</tt> if there
	 * is not a such attribute.
	 * 
	 * @param node
	 *            the node
	 * @param attrName
	 *            the name of the attribute
	 * @return the attribute value
	 */
	public static String getAttribute(final Node node, final String attrName) {

		nonNullArgument(node, "node");
		nonNullArgument(attrName, "attrName");

		final NamedNodeMap map = node.getAttributes();

		final int length = map.getLength();

		for (int i = 0; i < length; ++i) {

			final Node n = map.item(i);

			final String nodeName = n.getNodeName();

			if (attrName.equals(nodeName)) {

				return n.getNodeValue();
			}
		}

		return null;
	}

	/**
	 * return the text value of a given node. Use this utility method for the
	 * {@link Node}'s DOM 3 <tt>getTextContent()</tt> method exists in JDK 1.5,
	 * but not in 1.6 and/or not in certain old libraries.
	 * 
	 * @param node the DOM node.
	 * @return the text content.
	 */
	public static String getTextContent(final Node node) {

		nonNullArgument(node, "node");

		return node.getTextContent();
	}

	/**
	 * return the base URI of a given element. Use this utility method for the
	 * {@link Node}'s DOM 3 <tt>getBaseURI()</tt> method exists in JDK 1.5,
	 * but not in 1.6 and/or not in certain old libraries.
	 * 
	 * @param node the DOM node.
	 * @return the text content.
	 */
	public static String getBaseURI(final Node node) {

		nonNullArgument(node, "node");

		return node.getBaseURI();
	}

	/**
	 * create an attribute list. The attributes, given in the <tt>args</tt>
	 * parameter, must be given as follows: first attribute name ({@link String}
	 * ), first attribute value (any object. Will be converted with
	 * <tt>toString()</tt>).), second attribute name, second attribute value,
	 * etc.
	 * 
	 * @param args
	 *            the attributes to create the list from.
	 * @return the newly created attribute list
	 */
	@Nullable
	public static Attributes createAttributes(@Nullable final Object... args) {

		if (args == null) {

			return null;
		}

		final AttributesImpl attributes = new AttributesImpl();

		for (int i = 0; i < args.length; i += 2) {

			final Object name = args[i];
			final Object value = args[i + 1];

			nonNullArgument(name, "name: a[" + i + "]");

			if (!(name instanceof String)) {
				throw new ClassCastException("attributes[" + i
						+ "]'s is not a String: " + name);
			}

			nonNullArgument(value, "value: a[" + (i + 1) + "], for name: "
					+ name);

			final String n = (String) name;

			attributes.addAttribute(null, n, n, "CDATA", value.toString());
		}

		return attributes;
	}

	/**
	 * return an encoded XML text: [<tt>"</tt>] becomes [<tt>&amp;quot;</tt>], [
	 * <tt>&lt;</tt>] becomes [<tt>&amp;lt;</tt>], [<tt>&gt;</tt>] becomes [
	 * <tt>&amp;gt;</tt>], and [<tt>&amp;</tt>] becomes [<tt>&amp;amp;</tt>]. [<tt>'</tt>
	 * ] (apos) stays the same.
	 * 
	 * @param ch the char buffer to encode
	 * @param start the offset in the buffer for the first char to encode
	 * @param length the number of chars to encode
	 * @return the encoded chars
	 */
	public static String xmlEncode(final char[] ch, final int start,
			final int length) {

		nonNullArgument(ch, "chars");

		final String a = alreadyXmlEncoded(ch, start, length);

		if (a != null) {
			return a;
		}

		final StringBuffer s = new StringBuffer();

		for (int i = 0; i < length; ++i) {

			final char c = ch[start + i];

			switch (c) {
			case '<':
				s.append("&lt;");
				break;
			case '>':
				s.append("&gt;");
				break;
			case '&':
				s.append("&amp;");
				break;
			case '"':
				s.append("&quot;");
				break;
			default:
				s.append(c);
				break;
			}
		}

		return s.toString();
	}

	/**
	 * use this method to check if a given {@link String} is already
	 * XML-encoded, that is does not contain any special character.
	 * This method was written for <em>speed</tt>
	 * (and a bit of memory management), it then is normal to have
	 * some optimization code.
	 * 
	 * @param ch the char buffer to encode
	 * @param start the offset in the buffer for the first char to encode
	 * @param length the number of chars to encode
	 * @return the same text if it doesn't require XML encoding, or <tt>null</tt>
	 * if it does.
	 */
	private static String alreadyXmlEncoded(@Nullable final char[] ch,
			final int start, final int length) {

		// please do not suppress these optimizations.
		// Yes, we all are aware it would be simpler with a language that 
		// supports closures. Please do not alter these optimizations anyway.
		//
		if (length == ch.length) {

			for (final char c : ch) {

				switch (c) {
				case '<':
				case '>':
				case '&':
				case '"':
					return null;
				default:
					break;
				}
			}

		} else if (start == 0) {

			for (int i = 0; i < length; ++i) {

				switch (ch[i]) {
				case '<':
				case '>':
				case '&':
				case '"':
					return null;
				default:
					break;
				}
			}

		} else {

			for (int i = 0; i < length; ++i) {

				switch (ch[start + i]) {
				case '<':
				case '>':
				case '&':
				case '"':
					return null;
				default:
					break;
				}
			}
		}

		return new String(ch, start, length);
	}

	/**
	 * use this method to check if a given {@link String} is already
	 * XML-encoded, that is does not contain any special character.
	 * This method was written for <em>speed</tt>
	 * (and a bit of memory management), it then is normal to have
	 * some optimization code.
	 * 
	 * @param ch the char buffer to encode
	 * @param start the offset in the buffer for the first char to encode
	 * @param length the number of chars to encode
	 * @return the same text if it doesn't require XML encoding, or <tt>null</tt>
	 * if it does.
	 */
	private static String alreadyXmlEncodedChars(@Nullable final char[] ch,
			final int start, final int length) {

		// please do not suppress these optimizations.
		// Yes, we all are aware it would be simpler with a language that 
		// supports closures. Please do not alter these optimizations anyway.
		//
		if (length == ch.length) {

			for (final char c : ch) {

				switch (c) {
				case '<':
				case '>':
				case '&':
					return null;
				default:
					break;
				}
			}

		} else if (start == 0) {

			for (int i = 0; i < length; ++i) {

				switch (ch[i]) {
				case '<':
				case '>':
				case '&':
					return null;
				default:
					break;
				}
			}

		} else {

			for (int i = 0; i < length; ++i) {

				switch (ch[start + i]) {
				case '<':
				case '>':
				case '&':
					return null;
				default:
					break;
				}
			}
		}

		return new String(ch, start, length);
	}

	/**
	 * return an encoded XML text for chars only: [
	 * <tt>&lt;</tt>] becomes [<tt>&amp;lt;</tt>], [<tt>&gt;</tt>] becomes [
	 * <tt>&amp;gt;</tt>], and [<tt>&amp;</tt>] becomes [<tt>&amp;amp;</tt>]. [<tt>"</tt>"]
	 * and [<tt>'</tt>
	 * ] (apos) stay the same.
	 * 
	 * @param ch the char buffer to encode
	 * @param start the offset in the buffer for the first char to encode
	 * @param length the number of chars to encode
	 * @return the encoded chars
	 */
	public static String xmlEncodeChars(final char[] ch, final int start,
			final int length) {

		nonNullArgument(ch, "chars");

		final String a = alreadyXmlEncodedChars(ch, start, length);

		if (a != null) {
			return a;
		}

		final StringBuffer s = new StringBuffer();

		for (int i = 0; i < length; ++i) {

			final char c = ch[start + i];

			switch (c) {
			case '<':
				s.append("&lt;");
				break;
			case '>':
				s.append("&gt;");
				break;
			case '&':
				s.append("&amp;");
				break;
			default:
				s.append(c);
				break;
			}
		}

		return s.toString();
	}

	/**
	 * return an encoded XML text: [<tt>"</tt>] becomes [<tt>&amp;quot;</tt>], [
	 * <tt>&lt;</tt>] becomes [<tt>&amp;lt;</tt>], [<tt>&gt;</tt>] becomes [
	 * <tt>&amp;gt;</tt>], and [<tt>&amp;</tt>] becomes [<tt>&amp;amp;</tt>]. [<tt>'</tt>
	 * ] (apos) stays the same.
	 * 
	 * @param s the string to encode
	 * @return the encoded string
	 */
	public static String xmlEncode(final String s) {

		nonNullArgument(s, "s");

		return xmlEncode(s.toCharArray(), 0, s.length());
	}

	/**
	 * return an encoded XML text for chars only: [
	 * <tt>&lt;</tt>] becomes [<tt>&amp;lt;</tt>], [<tt>&gt;</tt>] becomes [
	 * <tt>&amp;gt;</tt>], and [<tt>&amp;</tt>] becomes [<tt>&amp;amp;</tt>]. [<tt>"</tt>"]
	 * and [<tt>'</tt>
	 * ] (apos) stay the same.
	 * 
	 * @param s the string to encode
	 * @return the encoded string
	 */
	public static String xmlEncodeChars(final String s) {

		nonNullArgument(s, "s");

		return xmlEncodeChars(s.toCharArray(), 0, s.length());
	}
}
